fix(spec): refuse an absent value on a value-taking view filter operator at authoring time - #19861
objectstack-fleet[bot] wants to merge 2 commits into
Conversation
…tor at authoring time
The scalar arm of the view filter rule value check returned early on an
absent value for every operator, so a rule such as
{ field: 'name', operator: 'icontains' } parsed green while its published
description declares a scalar for every non-unary operator, and the query
path refuses the lowered [field, operator] node with 400 INVALID_FILTER.
The four unary operators stay valueless; in / not_in / between keep their
own arms. Adds the ADR-0087 semantic entry under protocol major 18 and the
regenerated migration registry.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…ue arm The pin asserted that an icontains view rule with no value parses. That carve-out is gone: the value-shape check now refuses a missing value on every operator that takes one. The pin keeps its own half, that the text comparand door never judges absence: the refusal arrives once, in the absent-value arm's words, never in the conformance table's. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5540949db6c55ce6e901523ee796cc9b7dfa8419 && git checkout 5540949db6c55ce6e901523ee796cc9b7dfa8419
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 22a14a10384056741dda7a2d8e2626901bf8fd56 && git checkout -B drift-repro 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 && git merge --no-ff 22a14a10384056741dda7a2d8e2626901bf8fd56
node scripts/docs-audit/affected-docs.mjs --json 8cbc3c0084a3c3c8ef5e59763ab3be96aa1a6f07 |
Fixes #19751
Clause-②: no
What changes
checkViewFilterRuleValueShape(the value-shape refinement ofViewFilterRuleSchema,packages/spec/src/ui/view.zod.ts) now refuses a rule with NOvalueon every operator that takes one. Its scalar arm returned early onvalue === undefinedfor every operator, so{ field: 'name', operator: 'icontains' }parsed green, while the key's published description says every operator outsidein/not_in/betweenand the four unary operators takes a scalar, and the query path refuses the lowered rule with400 INVALID_FILTER.is_empty,is_not_empty,is_null,is_not_null) are answered first and stay valueless, with or without a value.in/not_in/betweenkeep their own arms, which already refused an absent value..optional()on the shape; the coupling lives in the refinement, like the other arms.value.describe()is unchanged (it already declares this contract), so no generated reference page moves.Refusal text, one issue at the rule's
valuepath:The leading sentence is the runtime's undefined-comparand sentence ("Filter comparand at PATH is undefined.") with the location named in the view vocabulary: operator and field, the same substitution the list and range arms already make. A view rule has no
wherepath, and the$spelling in that path is not one a view author can write. The unary operator names in the tail come from the schema's ownVIEW_FILTER_VALUELESS_OPERATORS.Producer reading (step 1): objectui at the pinned
.objectui-sha87af769e9a3ee28ace099fdd653d3ebd79fe82e2Read with
git show SHA:PATHfrom a local clone at that sha, not from a working tree.Does the console ever save a value-taking rule with no
value? No.foldFilterGroupToSpecRules, the one fold every view-filter writer sharespackages/app-shell/src/views/viewFilterFold.tsisFilterValueComplete(operator, value)is false (if (takesValue && isMissingValue(...)) continue)isFilterValueCompletepackages/components/src/custom/filter-builder.tsxvalue == null(also'',[], a half-filled pair), so an absent value is always incompleteFilterBuilderField/FilterBuilderWidget: thefilter-builderwidget thatview.form.tsnames forfilterandpage.form.tsforfilterBy, plus the per-tab filter editorpackages/app-shell/src/views/metadata-admin/widgets.tsxViewConfigPanelhosts the same inspector (ViewConfigPanel.tsx,ViewVariantInspector)packages/app-shell/src/views/ObjectView.tsxfoldUrlFilterTriplesToSpecRulespackages/app-shell/src/views/ObjectDataPage.tsxViewFilterRuleSchema.safeParseper rule, refused rules dropped; the URL triples (drillUrlFilters.ts,parseUrlFilterTriples) skip an empty param and always carry a valueOne edge, stated rather than hidden:
handleViewConfigSave(ObjectView.tsx) persists the config draft whole. A view whose STORED body already carries such a rule (hand-authored, or written by another tool) and is re-saved through the panel without its filter being touched now gets the refusal at save. That view already fails every query today (next table).Does anything drop a valueless row between storage and the query? No.
viewFilterRuleToNode, behindtoFilterNode/mergeFilterNodes(plugin-listbuildEffectiveFilter, plugin-viewObjectView,ObjectGrid,RelatedList,LineItemsPanel)packages/core/src/utils/filter-converter.tsvaluelowers to the 2-tuple[field, operator]and nothing skips it; its own comment records the runtime throwingINVALID_FILTER/ 400 for['name','icontains']lowerViewFilterRulepackages/rest/src/view-filter-rule-lowering.tspackages/metadata-protocol/src/protocol.tsisFilterAST, thenparseFilterAST, which throwsMeasured on this tree's spec source (4112752):
isFilterAST(['and', ['name','equals'], ['status','equals','open']])is true, andparseFilterASTof it throwsINVALID_FILTER/ 400, "Filter comparand at where.$and[0].name is undefined". One valueless rule fails the WHOLE view's query, its good rules included.So no working flow saves or executes this shape, and refusing it at save breaks nothing that works today.
Today's behaviour for the whole class (step 2)
Measured at
origin/main4112752 by script. The operator list isVIEW_FILTER_OPERATORSread at runtime; the unary set was derived by behaviour from the schema's own scalar arm (an array is refused on every non-list, non-range operator outside the private valueless set).ViewFilterRuleSchema,valueomitted, before this changeparseFilterAST([field, op])equals,not_equals,contains,not_contains,icontains,starts_with,ends_with,greater_than,less_than,greater_than_or_equal,less_than_or_equal,before,after(13)INVALID_FILTER/ 400, "Filter comparand at where.name (or where.name.$op) is undefined"in,not_inbetweenis_empty,is_not_empty,is_null,is_not_null{ "$null": true }/{ "$null": false }After this change the 13 are refused. The other rows are unchanged.
ADR-0087 reading (step 4)
scripts/check-changeset-no-major.mjs: the level does not carry breaking-ness, and "the mandatory information carriers for breaking-ness in the meantime are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition".scripts/check-adr-0087-registration.mjsthen requires a disposition on the declared-breaking changeset.registered, notnot-required. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), andno-migration-prescriptionis refused for a body that carries one. None of the other categories fits: the package publishes, no existing entry covers absence, and the surface is a schema, not a runtime interface or a type surface.view-filter-rule-scalar-operator-array-refused(the sibling arm of this same check) andfilter-preset-ordering-comparand-refused(a shape that never executed usefully) both registered a semantic entry under protocol major 18.packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts.packages/spec/src/migrations/registry.tswas regenerated bypnpm --filter @objectstack/spec gen:migration-registryand not hand-edited;check:migration-registryis green. No D2 conversion: there is no value to infer.check-adr-0087-registration --base origin/mainreads the changeset as[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here).spec-changes.jsonanddocs/protocol-upgrade-guide.mddid not move. The protocol-18 step stays inert until the protocol major reaches 18, andcheck:spec-changes/check:upgrade-guideare green without regeneration.Changeset (step 7)
.changeset/19751-view-filter-rule-absent-value-refused.md,patchon@objectstack/spec.files[]shipsdistandsrc/**/*.zod.ts, and both carry the refinement. Its summary is the user-visible change: a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query. It carries the BREAKING banner, a FROM/TO block,Clause-②: no (narrowing)and the registered disposition marker.patchfollows the dispatch and the "WHICH LEVEL" rule in.github/workflows/pr-automation.yml(the level is graded by the act, and a narrowing adds nothing). Both precedents above shippedminorwith the same banner, and thecheck-changeset-no-major.mjsheader says "we ship breaking changes asminor". Both levels pass the gates. This is left to the reviewing seat.The PR's
Clause-②: noline is the claim's, copied verbatim. The changeset adds the(narrowing)arm to the samenovalue, as the sibling precedent's changeset did, so that the ADR-0087 gate reads the direction from a token and not only from the banner.Fixtures, examples and pins (step 5)
.ts/.tsx/.mts/.js/.mjs/.jsonoutsidecontent/docs/references/(1,739 files mentionoperator) found 311 object literals withfieldplus a string-literal value-taking operator (aliases folded). 20 of them have novaluekey, and none is a view filter rule in a shipped example or seed:expectedValue, a different schema) inexamples/app-showcase/qa/platform-smoke.test.json;packages/core/src/qa/runner.test.tsandpackages/spec/src/qa/testing.test.ts;packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts;packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts;view-filter-rule-value-shape.test.ts.Markdown (
.md/.mdx) has no match. No fixture was an authoring mistake, so no fixture was edited.packages/spec/src/ui/view-filter-rule-value-shape.test.ts:equals + omittedandgreater_than + omitted, from accepted to refused.packages/spec/src/data/filter-icontains-parse-door.test.ts: "ABSENCE is left unjudged" now asserts that absence is refused once, in the absent-value arm's words and never in the conformance table's.src/data/, not besideview.zod.ts, and it had to move with the carve-out it pinned.ListView.filter, a tab filter,Page.filterBy, a related-list filter, a lookup picker filter, plusObjectGridProps.defaultFilters) are allz.array(ViewFilterRuleSchema). The full spec suite is green, and a new pin drives the refusal throughListView.filteratfilter.1.value.Tests
VIEW_FILTER_OPERATORSminus the four valueless operators. The valueless set is module-private inview.zod.tsand deliberately not exported, so the test reuses the file's existing transcription, and a new two-way sweep holds that transcription equal to the private set by behaviour: over every operator, an absent value is accepted exactly when the operator is valueless.vitest run --project localonview-filter-rule-value-shape.test.tsandfilter-icontains-parse-door.test.ts: 104 passed.scripts/ablation-replace.mjs: the anchorif (value === undefined) {was replaced byif (value === undefined) return;followed byif (false) {, which is the base behaviour (an absent value returns before any issue). The anchor went 1 to 0 and the blob b6b2f445 to 8c2839db. Result: 11 new pins red, 57 green. After the restore, the blob equals HEAD andgit diff HEADis empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt.localproject at 22a14a1: 522 files passed, 15,420 tests passed. One file skipped by its own stale-dist condition (scripts/root-entry-type-nameability.pin.test.ts); after a rebuild at the same head it ran withOS_EXPECT_ROOT_NAMEABILITY=1: 2 passed.repoproject at 22a14a1: 34 files, 587 tests passed.pnpm --filter @objectstack/spec typecheckat 22a14a1: exit 0 (tsc, scripts typecheck,check:test-typecheckOK).Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat 22a14a1 derived 86 commands. All were run and reconciled with--ran: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI.check:generatedis 15/15 up to date after a fresh build at that head.Generated files that moved:
packages/spec/src/migrations/registry.tsonly, viagen:migration-registry.Scope held
view.zod.ts, onlycheckViewFilterRuleValueShapeand its docblock changed. TheViewFilterRuleSchemablock is untouched: its JSDoc and.describe()are still true. None of PR fix(spec): one row bound per view — retire the unpublished per-kind viewlimit#19809's regions is touched.FILTER_TEXT_CASESgains no row. There are no objectui or runtime (parseFilterAST) edits.origin/mainhas moved 8 commits past the branch point: spec(ui): declare the columns x hiddenFields x fieldOrder composition on the list view, with pins and a re-cited liveness row (ruling B) #19598 touches theListViewshape inview.zod.ts, and feat(spec)!: retire connector.connectionTimeoutMs — carried everywhere, applied nowhere #19657 touchesregistry.ts. A driver-lessmerge-treeof HEAD ontoorigin/main8cbc3c0 is clean. Main is not merged in.Acceptance notes
view-filter-rule-scalar-operator-array-refusedsays, in its replacement prose, "An omitted value is still an omitted value". That was true of its own arm; after this change an omitted value on a scalar operator is refused. Both entries sit in the uncut protocol-18 step. The new entry's leading comment names the reversal, and the sibling's text was left as it is (it is outside this card's file surface).checkViewFilterRuleTextComparand's docblock, carve-out 1, says an omitted comparand "is left to whatever judges absence". That stays true: the shape arm now judges it. Not edited.Generated by Claude Code